package sandbox.dto;

import java.lang.reflect.Method;
import java.util.Collection;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.reflections.Reflections;
import org.springframework.data.jpa.repository.JpaRepository;
import sandbox.util.LogUtils;
import sandbox.util.ReflectionUtils;
import sandbox.util.RepoHelper;

public abstract class MappingManager {

    private class Mapping {
        public Class<?> dtoClass;
        public Class<?> entityClass;
        public final Map<String, AttributeMapping> attributes = new HashMap<String, AttributeMapping>();
    }

    private class Attribute {
        public String name;
        public Class<?> type;
        public Method getter;
        public Method setter;
        public Method idGetter;
    }

    private class AttributeMapping {
        public final Attribute dtoAttribute = new Attribute();
        public final Attribute entityAttribute = new Attribute();
        public boolean skipDecoding;
        public AttributeConverter converter;
        public CollectionMapping collectionMapping = null;
    }

    private class CollectionMapping {
        public Class<?> dtoItemsType;
        public Class<?> entityItemsType;
        public Method dtoIdGetter;
        public Method entityIdGetter;
    }

    private final Map<Class<?>, Mapping> dtoMappings;
    private final Map<Class<?>, Mapping> entityMappings;
    private Map<Class<?>, JpaRepository<?, Long>> repos;
    private final Set<Class> primitiveTypes;

    public MappingManager() {
        dtoMappings = new HashMap<Class<?>, Mapping>();
        entityMappings = new HashMap<Class<?>, Mapping>();
        primitiveTypes = new HashSet<Class>();
        repos = new HashMap<Class<?>, JpaRepository<?, Long>>();
    }

    protected void init(String packageName) {
        LogUtils.info(this, "Initializing EntityMappingManager");
        try {
            Reflections reflections = new Reflections(packageName);
            Set<Class<?>> mappableClasses = reflections.getTypesAnnotatedWith(EntityMapping.class);
            register(mappableClasses);
        } catch(Exception ex) {
            LogUtils.error(this, "Failed to register mappable classes", ex);
            dtoMappings.clear();
            entityMappings.clear();
        }
    }

    protected void registerPrimitiveType(Class<?> type) {
        primitiveTypes.add(type);
    }

    protected void registerRepo(Class<?> entityType, JpaRepository<?, Long> repo) {
        repos.put(entityType, repo);
    }

    private void register(Collection<Class<?>> mappableClasses) throws Exception {
        if(mappableClasses != null) {
            for(Class<?> dtoClass: mappableClasses) {
                LogUtils.info(this, "Registering DTO type " + dtoClass.getName());
                try {
                    Method[] methods = dtoClass.getMethods();
                    Mapping mapping = dtoMappings.get(dtoClass);
                    if(mapping == null) {
                        mapping = new Mapping();
                        EntityMapping a = dtoClass.getAnnotation(EntityMapping.class);
                        mapping.dtoClass = dtoClass;
                        mapping.entityClass = a.entityClass();
                        dtoMappings.put(dtoClass, mapping);
                        entityMappings.put(mapping.entityClass, mapping);
                    }
                    for(Method method: methods) {
                        if(method.isAnnotationPresent(sandbox.dto.AttributeMapping.class)) {
                            if(!method.getName().startsWith("get")) {
                                throw new Exception("Method '" + method.getName() + "' of class '" + dtoClass.getName() + "' marked as mappable is not getter (doesn't start with 'get')");
                            }
                            AttributeMapping attrMapping = new AttributeMapping();
                            sandbox.dto.AttributeMapping a = method.getAnnotation(sandbox.dto.AttributeMapping.class);
                            if(a.converter() == null) {
                                throw new Exception("Method '" + method.getName() + ": converter must not be null");
                            }
                            attrMapping.converter = a.converter().newInstance();
                            attrMapping.skipDecoding = a.skipDecoding();
                            sandbox.dto.CollectionMapping ca = method.getAnnotation(sandbox.dto.CollectionMapping.class);
                            if(ca != null) {
                                attrMapping.collectionMapping = new CollectionMapping();
                                attrMapping.collectionMapping.dtoItemsType = ca.dtoItemsType();
                                attrMapping.collectionMapping.entityItemsType = ca.entityItemsType();
                                if(ca.dtoIdField() != null && !ca.dtoIdField().isEmpty()) {
                                    attrMapping.collectionMapping.dtoIdGetter = attrMapping.collectionMapping.dtoItemsType.getMethod(ReflectionUtils.getGetterName(ca.dtoIdField()));
                                }
                                if(ca.entityIdField() != null && !ca.entityIdField().isEmpty()) {
                                    attrMapping.collectionMapping.entityIdGetter = attrMapping.collectionMapping.entityItemsType.getMethod(ReflectionUtils.getGetterName(ca.entityIdField()));
                                }
                            }
                            attrMapping.dtoAttribute.name = ReflectionUtils.getFieldName(method.getName());
                            LogUtils.info(this, "Registering DTO attribute " + attrMapping.dtoAttribute.name);
                            attrMapping.dtoAttribute.getter = method;
                            attrMapping.dtoAttribute.type = method.getReturnType();
                            attrMapping.dtoAttribute.setter = dtoClass.getMethod(ReflectionUtils.getSetterName(attrMapping.dtoAttribute.name), attrMapping.dtoAttribute.type);

                            if(a.name() != null && !a.name().isEmpty()) {
                                attrMapping.entityAttribute.name = a.name();
                            } else {
                                attrMapping.entityAttribute.name = attrMapping.dtoAttribute.name;
                            }
                            attrMapping.entityAttribute.getter = mapping.entityClass.getMethod(ReflectionUtils.getGetterName(attrMapping.entityAttribute.name));
                            attrMapping.entityAttribute.type = attrMapping.entityAttribute.getter.getReturnType();
                            if(a.dtoIdField()!= null && !a.dtoIdField().isEmpty()) {
                                attrMapping.dtoAttribute.idGetter = attrMapping.dtoAttribute.type.getMethod(ReflectionUtils.getGetterName(a.dtoIdField()));
                            }
                            if(a.entityIdField() != null && !a.entityIdField().isEmpty()) {
                                attrMapping.entityAttribute.idGetter = attrMapping.entityAttribute.type.getMethod(ReflectionUtils.getGetterName(a.entityIdField()));
                            }
                            if(!attrMapping.skipDecoding) {
                                attrMapping.entityAttribute.setter = mapping.entityClass.getMethod(ReflectionUtils.getSetterName(attrMapping.entityAttribute.name), attrMapping.entityAttribute.type);
                            }
                            checkMappingTypes(attrMapping);
                            mapping.attributes.put(attrMapping.entityAttribute.name, attrMapping);
                        }
                    }
                } catch(Exception ex) {
                    throw new Exception("Couldn't register mappable class " + dtoClass.getName(), ex);
                }
            }
        }
    }

    private void checkMappingTypes(AttributeMapping attrMapping) throws Exception {
        Class<?> dtoAttrType = attrMapping.dtoAttribute.type;
        Class<?> entityAttrType = attrMapping.entityAttribute.type;
//        boolean skipDecoding = attrMapping.skipDecoding;
        if(isPrimitive(entityAttrType)) {
            if(!isPrimitive(dtoAttrType)) {
                throwTypesMismatch(dtoAttrType.getName(), entityAttrType.getName());
            }
        } else if(isFindableType(entityAttrType)) {
            if(!(isIdReference(dtoAttrType) && attrMapping.entityAttribute.idGetter != null)
                && !(isMappableType(dtoAttrType) && (attrMapping.skipDecoding || attrMapping.dtoAttribute.idGetter != null))) {
                throwTypesMismatch(dtoAttrType.getName(), entityAttrType.getName());
            }
        } else if(isCollection(entityAttrType)) {
            if(attrMapping.collectionMapping == null) {
                throw new Exception("@CollectionMapping annotation must be set");
            }
            CollectionMapping collMapping = attrMapping.collectionMapping;
            if(!isCollection(dtoAttrType)) {
                throwTypesMismatch(dtoAttrType.getName(), "Collection of " + collMapping.entityItemsType.getName());
            }
            if(isPrimitive(collMapping.entityItemsType)) {
                if(!isPrimitive(collMapping.dtoItemsType)) {
                    throwTypesMismatch("Collection of " + collMapping.dtoItemsType.getName(), "Collection of " + collMapping.entityItemsType.getName());
                }
            } else if(isFindableType(attrMapping.collectionMapping.entityItemsType)) {
                if(!(isIdReference(collMapping.dtoItemsType) && collMapping.entityIdGetter != null)
                    && !(isMappableType(collMapping.dtoItemsType) && (attrMapping.skipDecoding || collMapping.dtoIdGetter != null))) {
                    throwTypesMismatch("Collection of " + collMapping.dtoItemsType.getName(), "Collection of " + collMapping.entityItemsType.getName());
                }
            } else if(!isMappedType(collMapping.entityItemsType, collMapping.dtoItemsType)) {
                throwTypesMismatch("Collection of " + collMapping.dtoItemsType.getName(), "Collection of " + collMapping.entityItemsType.getName());
            }
        } else if(!isMappedType(entityAttrType, dtoAttrType)) {
            throwTypesMismatch(dtoAttrType.getName(), entityAttrType.getName());
        }
    }

    private void throwTypesMismatch(String dtoType, String entityType) throws Exception {
        throw new Exception("DTO and entity attribute types mismatch. Found DTO attribute type: " + dtoType + "; entity attribute type: " + entityType);
    }

    private boolean isPrimitive(Class type) {
        return type.isAssignableFrom(Long.class) || type.isAssignableFrom(Double.class)
                || type.isAssignableFrom(Integer.class) || type.isAssignableFrom(Date.class)
                || type.isAssignableFrom(String.class) || type.isAssignableFrom(Boolean.class);
    }

    private boolean isIdReference(Class dtoAttrType) {
        return dtoAttrType.isAssignableFrom(Long.class);
    }

    private boolean isCollection(Class entityAttrType) {
        return Collection.class.isAssignableFrom(entityAttrType);
    }

    private boolean isFindableType(Class entityAttrType) {
        return repos.containsKey(entityAttrType);
    }

    private boolean isMappedType(Class<?> entityAttrType, Class<?> dtoAttrType) {
        if(isMappableType(dtoAttrType)) {
            EntityMapping a = dtoAttrType.getAnnotation(EntityMapping.class);
            return a != null && a.entityClass().equals(entityAttrType);
        } else {
            return false;
        }
    }

    private boolean isMappableType(Class<?> dtoAttrType) {
        return dtoAttrType.isAnnotationPresent(EntityMapping.class);
    }

    public <T> Collection<Object> buildDtos(Collection<T> entities, DtoPostBuildProcessor<?, T>... postBuilders) throws Exception {
        List<Object> dtos = new LinkedList<Object>();
        for(T entity: entities) {
            dtos.add(buildDto(entity, postBuilders));
        }
        return dtos;
    }

    public <T> Object buildDto(T entity, DtoPostBuildProcessor<?, T>... postBuilders) throws Exception {
        Mapping mapping = entityMappings.get(entity.getClass());
        try {
            Object dto = mapping.dtoClass.newInstance();
            for(AttributeMapping attrMapping: mapping.attributes.values()) {
                buildAttribute(attrMapping, entity, dto, true);
            }
            for(DtoPostBuildProcessor postBuilder: postBuilders) {
                postBuilder.process(dto, entity);
            }
            return dto;
        } catch(ReflectiveOperationException ex) {
            throw new Exception("Couldn't create new object of DTO type " + mapping.dtoClass.getName());
        }
    }

    public <D> Object buildEntity(D dto) throws Exception {
        if(dto == null) {
            throw new Exception("DTO object is null");
        }
        Mapping mapping = dtoMappings.get(dto.getClass());
        if(mapping == null) {
            throw new Exception("Not found DTO mapping for type " + dto.getClass().getName());
        }
        try {
            return buildEntity(dto, mapping.entityClass.newInstance());
        } catch(ReflectiveOperationException ex) {
            throw new Exception("Couldn't create new object of entity type " + mapping.entityClass.getName(), ex);
        }
    }

    public <D> Object buildEntity(D dto, Object entity) throws Exception {
        if(dto == null) {
            throw new Exception("DTO object is null");
        } else if(entity == null) {
            throw new Exception("Entity object to be updated is null");
        }
        Mapping mapping = dtoMappings.get(dto.getClass());
        if(mapping == null) {
            throw new Exception("Not found DTO mapping for type " + dto.getClass().getName());
        } else if(!entity.getClass().equals(mapping.entityClass)) {
            throw new Exception("DTO mapping for type " + dto.getClass().getName() + " doesn't match entity type " + entity.getClass().getName());
        }
        for(AttributeMapping attrMapping: mapping.attributes.values()) {
            if(!attrMapping.skipDecoding) {
                buildAttribute(attrMapping, dto, entity, false);
            }
        }
        return entity;
    }

    private void buildAttribute(AttributeMapping attrMapping, Object src, Object dst, boolean isDirect) throws Exception {
        Attribute srcAttribute;
        Attribute dstAttribute;
        if(isDirect) {
            srcAttribute = attrMapping.entityAttribute;
            dstAttribute = attrMapping.dtoAttribute;
        } else {
            srcAttribute = attrMapping.dtoAttribute;
            dstAttribute = attrMapping.entityAttribute;
        }
        AttributeConverter converter = attrMapping.converter;
        CollectionMapping collMapping = attrMapping.collectionMapping;
        Method getter = srcAttribute.getter;
        Method setter = dstAttribute.setter;
        try {
            Object srcValue = getter.invoke(src);
            Object dstValue;
            if(isDirect) {      // build DTO from entity
                if(isPrimitive(srcAttribute.type)) {
                    dstValue = converter.convert(srcValue);
                } else if(isCollection(srcAttribute.type)) {
                    Collection srcItems = (Collection) srcValue;
                    Collection dstItems = new LinkedList();
                    if(srcItems != null && !srcItems.isEmpty()) {
                        if(collMapping == null) {
                            throw new Exception("Collection mapping is undefined");
                        }
                        if(isPrimitive(collMapping.entityItemsType)) {
                            for(Object srcItem: srcItems) {
                                dstItems.add(converter.convert(srcItem));
                            }
                        } else if(isFindableType(collMapping.entityItemsType)) {
                            Method idGetter = collMapping.entityIdGetter;
                            if(isPrimitive(collMapping.dtoItemsType)) {
                                if(idGetter == null) {
                                    throw new Exception("Entity attribute id getter is undefined");
                                }
                                for(Object srcItem: srcItems) {
                                    dstItems.add(idGetter.invoke(srcItem));
                                }
                            } else {
                                for(Object srcItem: srcItems) {
                                    dstItems.add(buildDto(srcItem));
                                }
                            }
                        } else {
                            for(Object srcItem: srcItems) {
                                dstItems.add(buildDto(srcItem));
                            }
                        }
                    }
                    dstValue = dstItems;
                } else {
                    Method idGetter = attrMapping.entityAttribute.idGetter;
                    if(isPrimitive(dstAttribute.type)) {
                        dstValue = (idGetter.invoke(srcValue));
                    } else {
                        dstValue = buildDto(srcValue);
                    }
                }
            } else {            // build entity from DTO
                if(isPrimitive(srcAttribute.type)) {
                    dstValue = converter.deconvert(srcValue);
                } else if(isFindableType(dstAttribute.type)) {
                    Method idGetter = attrMapping.entityAttribute.idGetter;
                    JpaRepository<?, Long> repo = repos.get(srcAttribute.type);
                    if(isIdReference(dstAttribute.type)) {
                        dstValue = RepoHelper.find(srcAttribute.type, repo, (Long) srcValue);
                    } else {
                        dstValue = RepoHelper.find(srcAttribute.type, repo, (Long)idGetter.invoke(srcValue));
                    }
                } else if(isCollection(dstAttribute.type)) {
                    Collection srcItems = (Collection) srcValue;
                    Collection dstItems = new LinkedList();
                    if(srcItems != null && !srcItems.isEmpty()) {
                        if(collMapping == null) {
                            throw new Exception("Collection mapping is undefined");
                        }
                        if(isPrimitive(collMapping.entityItemsType)) {
                            for(Object srcItem: srcItems) {
                                dstItems.add(converter.convert(srcItem));
                            }
                        } else if(isFindableType(collMapping.entityItemsType)) {
                            Method idGetter = collMapping.dtoIdGetter;
                            JpaRepository<?, Long> repo = repos.get(collMapping.entityItemsType);
                            if(isPrimitive(collMapping.dtoItemsType)) {
                                for(Object srcItem: srcItems) {
                                    Long id = (Long) srcItem;
                                    dstItems.add(RepoHelper.find(collMapping.entityItemsType, repo, id));
                                }
                            } else {
                                for(Object srcItem: srcItems) {
                                    Long id = (Long) idGetter.invoke(srcItem);
                                    dstItems.add(RepoHelper.find(collMapping.entityItemsType, repo, id));
                                }
                            }
                        } else {
                            for(Object srcItem: srcItems) {
                                dstItems.add(buildEntity(srcItem));
                            }
                        }
                    }
                    dstValue = dstItems;
                } else {
                    dstValue = buildEntity(srcValue);
                }
            }
            setter.invoke(dst, dstValue);
        } catch(ReflectiveOperationException ex) {
            throw new Exception("Failed to set attribute " + dstAttribute.name + " from attribute " + srcAttribute.name, ex);
        }
    }
}
